Skip to content

Add microphone selection to Settings - #163

Merged
alexkroman merged 11 commits into
mainfrom
feat/mic-device-selection
Aug 26, 2026
Merged

Add microphone selection to Settings#163
alexkroman merged 11 commits into
mainfrom
feat/mic-device-selection

Conversation

@claude

@claude claude Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Requested by Mez · Slack thread

Before: Blurt always records from the system default input, via a per-session AVAudioRecorder writing a temp WAV. After: a Microphone dropdown in Settings › General — "Same as system (current default's name)" plus each input device — and capture runs on a single per-session AVCaptureSession recorder that binds to the pinned device (or the default), converts to the dictation API's 16 kHz mono S16LE in its data output, and accumulates the upload blob in memory. The choice persists as the device UID, and dictation falls back to the system default whenever the pinned device isn't connected (without unpinning).

What & why

Lets the user pin dictation to a specific microphone instead of always following the system default — e.g. keep AirPods for output while dictating into a USB mic.

How (single path, at Alex's direction): this PR originally shipped two backends — the untouched AVAudioRecorder WAV path for "same as system" plus a device-pinned AudioQueue. Per Alex Kroman's direction it now has one: CaptureSessionRecorder, an AVCaptureSession built fresh per capture behind MicCapture's CaptureRecorder seam (the fresh-recorder-per-session invariant is preserved; the AVAudioEngine/installTap ban is untouched and its check-invariants.sh anchors still pass --self-test; the settled-decisions prose in AGENTS.md / the guardrails skill / the script was updated consistently). Device selection is AVCaptureDevice(uniqueID:) — on macOS the uniqueID is the CoreAudio UID MicDeviceStore persists — with the pure, unit-tested missing-device fallback (MicDeviceSelection.effective) unchanged. The liveness gate's inputs are re-derived with MicLiveness itself unchanged: the clock is frames actually delivered (summed per sample buffer), power is the connection's AVCaptureAudioChannel.averagePowerLevel; fail-closed timeout, transport-keyed caps, and the Bluetooth tail linger still key off the resolved device's CoreAudio snapshot. Warm-up pre-builds the session without startRunning() — the device stays closed and no input indicator shows while idle — so route activation lands inside the press's connecting window (the liveness budget starts only after record() returns). A built-but-idle session holds nothing open, so the 60 s warm expiry (which existed to un-pin AirPods from their degraded output profile) is deleted.

Verify on real hardware (please read)

  • MicLiveness.silenceFloorDB (-115 dBFS) — the load-bearing one. Kept at -115: the dBFS math is meter-independent (full scale = 0, one int16 LSB ≈ -90.3, digital silence at/near the meter floor, -115 in the empty band between). But it was calibrated on AVAudioRecorder's meter; where AVCaptureAudioChannel.averagePowerLevel actually bottoms out on zero-filled buffers — and what it reads before its first update — must be confirmed, or the gate could fail open. AirPods mid A2DP→HFP switch is the case that matters: the gate must keep holding on all-zero buffers until real samples flow. AudioInputDevicesTests (env-gated, BLURT_LIVE_AUDIO_TESTS=1) now asserts a live mic out-reads the floor.
  • Warm/start latency: warm-up no longer pre-opens the route; startRunning() cost lands at every press, inside the connecting pill. Measure it wired and on AirPods against the old feel (the liveness frame-arrival caps — 300 ms local / 1 s unknown / 2.5 s BT — start after startRunning() returns, so they shouldn't need retuning, but the perceived press→chime time may grow).
  • Tail linger on AirPods: confirm the 220 ms Bluetooth linger still recovers the last word with the session backend.
  • Uniqueness assumption: AVCaptureDevice(uniqueID:) accepting the CoreAudio UID string for every device the picker lists (aggregates/virtual devices included).

Notes for review:

  • App UI lives in the existing SoundStepView.swift (+ UITestIdentifiers, already in both targets), so project.yml/project.pbxproj are untouched — written on Linux where xcodegen can't run.
  • Engine imports gained CoreMedia (block-buffer copy in the delegate; ships under AVFoundation's umbrella). The AudioQueue backend and its AudioToolbox import are gone. No SPM dependencies.
  • The temp-WAV write/read-back and decodePCM are gone with the old backend — the release path hands the accumulated S16LE straight to upload.

How it was tested

On Linux — can't build/test Swift locally; CI on macOS is the authority. scripts/check.sh --portable passes (exit 0) including check-invariants.sh and its --self-test (the reworded settled-decision prose stays pinned in all three places). Suites: MicDeviceStoreTests (decode/round-trip/fallback), PersistedSettingsTests (roster 12 keys), MicCaptureFormatTests (meter math + error wording), the warm suite rewritten for the expiry-free lifecycle (identity probe, device/pin validation), live-gated AudioInputDevicesTests (enumeration, UID round-trip, session-recorder capture + silence-floor assertion — engine tests never touch real CoreAudio ungated), and the SettingsUITests picker case ("Same as system" default only).

  • scripts/check.sh passes (or CI will, if I'm not on a Mac) — portable subset passes; full check runs on CI
  • I read AGENTS.md and this doesn't reintroduce anything deliberately removed (the capture-backend prose was updated at the owner's direction; the bans stand)
  • Docs updated if behavior changed (AGENTS.md table + MicCapture section + repo map, engine README, guardrails skill)

claude added 4 commits August 25, 2026 23:00
A Microphone picker in Settings > General pins dictation to a specific
input device, persisted as the device's CoreAudio UID (MicDeviceStore /
BlurtMicDeviceUID). Un-pinned capture — the default — keeps the shipped
AVAudioRecorder WAV path untouched; a pinned capture records through a
fresh-per-session AudioQueue bound to the device via
kAudioQueueProperty_CurrentDevice, behind a new CaptureRecorder seam
inside MicCapture.

The transport-keyed policies (liveness timeout, Bluetooth tail linger)
and the warm-recorder identity check key off the pinned device's
snapshot; a pinned device that isn't connected falls back to the system
default per press (MicDeviceSelection.effective, pure and unit-tested)
without unpinning. Device enumeration and UID translation live in
AudioInputDevices (hardware-bound, coverage-excluded like AudioRoute).

Engine tests stay off real CoreAudio: the new live suites ride the
BLURT_LIVE_AUDIO_TESTS gate, and the pure decode/fallback/store rules
are covered by MicDeviceStoreTests and the roster tests (now 12 keys).
- AudioQueueNewInput takes a capture-free closure literal forwarding to
  the static callback: a C function pointer cannot be formed from a
  static-method reference, only a top-level func or a literal closure.
- The static callback drops the unused packet timing/description
  parameters (raw LPCM never needs them), which also satisfies
  swiftlint's five-parameter limit.
- MicCapture.start(): break the warm-take/make-backend assignment the
  way swift-format asks (AddLines at the try).
Swift refuses the implicit inout-to-UnsafeRawPointer conversion for a
variable whose type carries an object reference, so the
kAudioQueueProperty_CurrentDevice value (the CFString reference itself)
goes through withUnsafeMutablePointer — the same pattern
AudioInputDevices already uses for the UID-translation qualifier, which
this CI run compiled cleanly.

Also swap the one key-path-inside-#expect in AudioInputDevicesTests for
an explicit closure, per AGENTS.md's rethrows/key-path macro trap.
Owner-directed (Alex Kroman, 2026-08-25): replace both capture backends
— the AVAudioRecorder/WAV path and the device-pinned AudioQueue — with
one AVCaptureSession recorder (CaptureSessionRecorder) behind the
existing CaptureRecorder seam. Still fresh per session: the session is
built around the press-time resolution of the selection (pinned device
via AVCaptureDevice(uniqueID:), else the default input, with the same
pure missing-device fallback), its data output converts to 16 kHz mono
16-bit LPCM, and the delegate accumulates upload-ready S16LE in memory
— no temp file, no decode pass.

Liveness gate inputs re-derived on the new API with MicLiveness itself
unchanged: the clock is the frames actually delivered (summed off each
sample buffer), power is the connection's AVCaptureAudioChannel
averagePowerLevel; fail-closed timeout, transport-keyed caps, and the
Bluetooth tail linger all still key off the resolved device's CoreAudio
snapshot. silenceFloorDB stays -115 dBFS — the dBFS math (0 = full
scale, one int16 LSB ~ -90) is meter-independent — but was calibrated
on the retired meter and must be re-verified on hardware.

Warm-up now pre-builds the session without starting it — the device
stays closed and no input indicator shows while idle — so route
activation lands inside the connecting window at record()'s
startRunning(). A built-but-idle session holds nothing open, so the
60 s warm expiry (which existed to un-pin AirPods from their degraded
output profile) is deleted along with its generation tickets.

Settled-decision prose updated consistently in AGENTS.md's table, the
project-guardrails skill, and check-invariants.sh's advice string; the
AVAudioEngine/installTap ban is unchanged and its anchors still pass
--self-test. Tests updated to the new backend (warm suite identity
probe replaces the generation counter; live suites stay env-gated).
@claude
claude Bot marked this pull request as ready for review August 25, 2026 23:41
alexkroman-assembly and others added 7 commits August 26, 2026 12:33
The warm recorder existed to pre-pay a cost it never paid. Measured on
hardware against CoreAudio's own kAudioDevicePropertyDeviceIsRunningSomewhere
— the bit behind the input indicator — building an AVCaptureSession leaves
the device closed and costs ~15 ms, while record()'s startRunning() opens it
and costs 180 ms on the built-in mic, ~600 ms on a USB interface. The retired
AVAudioRecorder measured the same way: prepareToRecord() is ~3 ms, leaves the
device closed, and a cold record() after it cost 614 ms against 585 ms with no
prepare at all. Neither API ever pre-paid the route activation, so the
machinery built on the premise that one did was buying ~15 ms of a ~600 ms
bring-up: a recorder re-warmed after every capture, validated against the
resolved device identity and the pin it was built under, a 60 s expiry (since
deleted with the AudioQueue backend) to un-pin AirPods from their degraded
output profile, and a bringingUpCapture flag whose only job was to stop a
re-warm racing a live press.

warmUp() is now stateless: build one session for the current selection and
drop it, which absorbs the ~75 ms a process pays the first time it touches
AVFoundation's capture stack and holds nothing afterwards. The live suite
pins the load-bearing half — building a recorder, and warming up, must leave
the microphone closed, and record() must still open it — with a test-local
HAL read rather than production code that only tests would call.

Deleting warm removed the only consumer of the device identity a resolved
input carried, which collapsed the rest:

- CaptureRecorder is gone. It was a seam over two backends (the WAV recorder
  and the pinned AudioQueue); with one conformer left and no test double
  behind it, MicCapture uses CaptureSessionRecorder directly and the contract
  the protocol documented moved onto its members. MicCaptureProtocol is still
  the seam hosts and tests inject at.
- AudioRoute.InputSnapshot is gone. A press needs the UID to pin and the
  transport for the liveness cap and tail linger; nothing asks which device it
  is anymore.
- Enumeration, naming and presence moved to AVCaptureDevice, which is the API
  the recorder already opens the device with — uniqueID *is* the CoreAudio UID
  on macOS, confirmed round-tripping against built-in, USB and virtual devices
  — deleting a device-list read, an input-stream filter, a CFString property
  bridge and a UID→AudioDeviceID translation. "The pin resolves" and "the
  recorder can open it" are now one fact instead of two that could disagree.
  The transport read deliberately stays on CoreAudio: AVCaptureDevice exposes
  transportType with the same four-character codes, but that is unconfirmed on
  a Bluetooth device, and a transport that fails to read as Bluetooth silently
  costs the 2.5 s cap and the 220 ms linger — the missing-last-word bug both
  exist to fix. Verifying it with AirPods connected is the one thing left.
- stopAndReadPCM no longer throws (the bytes are already in memory in the
  upload encoding — no read-back, decode or temp file left to fail at), and
  the liveness gate's clock probe is a frame count rather than a TimeInterval
  manufactured by dividing one by the sample rate.

Prose that rested on the false premise is corrected rather than deleted:
CueSoundPlayer's .connecting re-prime is still right, but because the *press*
flips the AirPods profile, not because warm-up did; AppCoordinator's launch
warm absorbs first-touch set-up, not route discovery; and MicLiveness no
longer credits a re-warm with keeping the input open between dictations. A new
settled-decisions row and guardrails bullet record the measurement, since "warm
the mic to make presses feel faster" is exactly what gets re-added.

swift test (628), the live audio suite (4, serialized — they share a device),
the coverage gate (89.16%), both sanitizers, periphery and check-invariants
--self-test all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the item the previous commit deliberately left open. The transport
read was the one piece of the input path kept on CoreAudio, because
AVCaptureDevice.transportType was confirmed to report the same four-character
codes for built-in, USB, virtual and aggregate devices but *not* for
Bluetooth — and Bluetooth is the only case the policies turn on. A transport
that failed to read as `blue` would silently cost the 2.5 s liveness cap and
the 220 ms tail linger, which is the missing-last-word bug both exist to fix.

Measured with AirPods connected: AVCaptureDevice reports `blue`, so the
parallel HAL read is gone — with it the UID→AudioDeviceID translation and
AudioRoute's input half. AudioRoute is now just the default *output* device
(AVCaptureDevice describes capture devices, so AudioRouteMonitor has no
equivalent) plus the property addressing the two share.

The same session surfaced two things the wired hardware could not, both in
the live suite rather than the product:

- `record()` returns before the device is open on Bluetooth. `startRunning()`
  takes ~80 ms on AirPods and the first frame lands ~410 ms later, where on a
  USB interface `startRunning()` blocks the whole ~600 ms and frames follow
  ~8 ms behind. The gate already handles it — that is what it is for, and the
  wait's clock starting after record() returns is what keeps the caps tight —
  but the new engagement test asserted the device was open the instant record()
  returned, which is true only on transports that block. It polls now.
- The capture meter has a second not-ready value. Until AVCaptureAudioChannel's
  first update it reports -Float.greatestFiniteMagnitude (~-3.4e38), not the
  -160 a settled meter floors at, and that update can land *after* the first
  frames. Both consumers already treat it as silence: MicLiveness's
  `!(power > floor)` spelling counts an out-of-range reading as not-live, and
  linearLevel floors it. This also settles the "silenceFloorDB was calibrated
  on the retired meter, re-verify on hardware" caveat from the backend move:
  -115 dBFS sits correctly between the sentinel and a live AirPods mic.

Both are now documented where the values are read, and the recorder test
polls for frames and power instead of sleeping a fixed 500 ms — that sleep sat
~10 ms from the AirPods first-frame time and would have flaked on a cold link.

check.sh green: 628 tests, 5 live tests on AirPods (serialized), coverage
89.16%, both sanitizers, periphery, app build, check-invariants --self-test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`record()` runs `AVCaptureSession.startRunning()`, which blocks for as long as
the hardware takes to open — ~180 ms on the built-in mic, ~600 ms on a USB
interface. Called inline from the actor it blocked `MicCapture` for that whole
window. Measured: a teardown arriving during the open waited 578 ms on a 635 ms
open. It also parked a cooperative-pool thread on a blocking call for the
duration — the same pool the context capture that deliberately overlaps the
bring-up and the transcriber's connection warm-up are running on, and starving
that pool is how covering the retired backend in CI deadlocked the entire test
run.

`record()` is now async and hops to a dedicated serial sessionQueue on the
recorder, so the open suspends the press instead of blocking anything. The same
teardown now takes 24 µs. A DispatchQueue rather than a detached Task on
purpose: the point is to park a Dispatch thread instead of a cooperative one.
It is also deliberately not the delegate queue — blocking that for the length
of an open would stall the very frames the liveness gate then waits for.

Scope of the win, stated honestly: through DictationSession this is latent
rather than user-visible. Its serial command queue already runs release and
cancel only after the press turn completes, and cancel() on `.connecting`
claims `.cancelled` synchronously without touching the mic, so Escape already
answered the UI immediately. What was actually blocked was the teardown, and
any host calling this public actor without serializing its own commands.

stopRunning() stays inline: 19–41 ms against the open's ~600 isn't worth
another suspension point on the release path the transcript waits behind, and
it cannot race the queue — every caller runs on a recorder whose record() has
already resumed.

The new suspension moves the teardown snapshot: stopGeneration is read before
the open rather than before the liveness wait, and checked at both ends, so a
stop or cancel landing while the input is coming up wins and the recorder is
torn down instead of installed. Without that, a press could install a live
recorder into a session whose caller had already been told the stop was clean.

MicCaptureBringUpTests pins the property. It calibrates against the real
device and skips its own assertion when the input opens too fast for the
window to be observable (AirPods open in ~100 ms), with
BLURT_LIVE_AUDIO_INPUT_UID to point it at a slower input — which is how the
USB case was covered on a machine whose default input is Bluetooth.

check.sh green: 628 tests, 6 live tests, coverage 89.09%, thread and address
sanitizers, periphery, app build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four review angles (reuse, simplification, efficiency, altitude) over
main...HEAD; findings deduped and applied. Two agents measured rather than
guessed, which changed two of my own numbers and caught one false comment.

Engine:

- start() had four hand-copied `recorder.stopAndDiscard()` exits and two
  verbatim copies of the abandonment test. The teardown is now one
  `defer { if !installed { … } }`, so a future suspension point inherits it and
  the failure mode of forgetting one — a live session with no owner, hot input
  indicator — can't come back. The two abandonment checks keep their distinct
  positions and log lines; only the duplicated bodies went.
- resolveInput did two device lookups of the same device per press to ask one
  question. AudioInputDevices.transportType(forUID:) now answers both: it
  returns nil exactly when no connected device carries the pin, since a
  transport read can't fail for a device that exists. isConnected is deleted,
  and CaptureSessionRecorder resolves its pin through the same
  AudioInputDevices.device(forUID:) — the two layers had drifted, the recorder's
  spelling missing the isConnected guard, so a device that had just gone away
  could be attached at one layer and called missing at the other.
- The session *build* joins the open off-actor. This was the /simplify finding I
  first skipped, on the grounds that folding build into "make and start" would
  destroy the live test's ability to assert that building leaves the mic closed.
  Hopping it separately keeps both steps observable: make() and record() are
  distinct async entry points on one static controlQueue (also now serializing
  session control across recorders, per AVFoundation's guidance). The slimmed
  bring-up test is what caught this — with its calibration probe gone, the
  teardown measured 100 ms instead of 24 µs, all of it the inline build.
- warmUp() read the full press-time input resolution to use one field of it,
  which spent two device lookups on a value the recorder re-resolves and could
  log "pinned microphone not connected" at launch, where nothing is recording.
  It reads MicDeviceSelection.pinnedUID now.
- activeTransportType stored a raw transport whose only use was deriving a
  Duration, and was never cleared, so a stop with no capture computed a linger
  from the previous one. It stores the Duration, cleared on detach — which also
  removes stop()'s read-before-the-suspension caveat.
- logName was derivable state on the hardware class, spelling a string for a
  caller that held the pin. Dropped; MicCapture logs the device instead.
- The upload geometry was six literals in a coverage-excluded file, and this
  branch had deleted its only covered assertion along with decodePCM. Channels
  and bit depth now come from SyncSTTLimits beside the byte math they must agree
  with, and MicCaptureFormatTests pins the link — a comment I wrote last commit
  claimed that test existed, and a reviewer caught that it didn't.

Tests:

- The BLURT_LIVE_AUDIO_TESTS gate was three copies of one condition and message;
  it and the .liveAudio tag now live in LiveAudioSupport.swift.
- MicCaptureBringUpTests dropped ~40 lines of calibration machinery, an
  undocumented env var, and a print-and-return that made it silently vacuous on
  any fast input. It asserts an absolute budget (10 ms against a measured 24 µs)
  which holds on every device, warms first the way the app does so it measures
  the property rather than first-touch cost, and now also asserts the press
  throws CancellationError — i.e. that the abandoned recorder was not installed.
- Three live suites open the same device while running in parallel, and one of
  them asserts on kAudioDevicePropertyDeviceIsRunningSomewhere, which answers
  for the device rather than for our client. It failed that way twice out of
  two. LiveAudioDevice.acquire()/release() serializes them; three consecutive
  runs green.

App:

- MicrophoneStepView moved to its own file (its stated reason for living inside
  SoundStepView — keeping the generated project unchanged — doesn't hold, since
  project.yml adds sources by directory; xcodegen regenerated).
- The picker binds MicDeviceSelection rather than the raw String, so
  ""-means-system is spelled once in the engine instead of again in a .tag("").
- Device enumeration moved off the main actor. It is a process's first touch of
  the capture stack when Settings opens before any dictation — measured 150–500
  ms cold — and it ran inline in onAppear while the window was laid out.

Prose made stale by this branch, including three files outside it: the
swift6-concurrency-reviewer subagent still told reviewers that AVAudioRecorder
was the deliberate backend, so it would have defended a backend this PR deleted
and flagged CaptureSessionRecorder as the violation; README.md's module map said
the same; MicCapture+Meter, DictationSession (twice) and a test still explained
themselves in terms of a disk read-back that no longer happens.

Skipped, with reasons: re-adding an injected recorder seam so the bring-up logic
is testable without hardware (a design decision that reverses 477efef, not a
cleanup — raising it separately); reading the transport off the recorder's
attached device (closes a narrow resolve→build race but moves the fallback
policy call into the recorder); warming the mic when permission is granted (new
behavior); a per-buffer allocation and a meter lookup both measured too cheap to
matter (0.035% of a core, 30 µs/s).

check.sh green: 630 tests, 6 live tests on real hardware, coverage 88.92%, both
sanitizers, periphery, app build, invariants --self-test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`.claude/agents/swift6-concurrency-reviewer.md` told reviewers that cleanup
"rides in the Sync STT `prompt`" — briefing a subagent that a field exists which
AGENTS.md and the project-guardrails skill both forbid reintroducing, and which
`ConversationContext.swift:30` says outright was replaced. Cleanup rides the
`llm` block's `instruction` (`CleanupInstruction`); the steering field is
`config.conversation_context`.

Missed in fb67c86, which corrected the AVAudioRecorder bullet three lines above
it in the same block. Caught by a reviewer reading the file afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sion

`meteredPowerDB()` read `connection.audioChannels.first`, and `audioChannels`
describes the *device's* channels, not the mono the data output converts to.
Measured on this machine: a stereo interface, an aggregate and two virtual
devices all report audioChannels=2 against outputChannels=1, and a channel
carrying nothing reads -758 dBFS — far below silenceFloorDB (-115).

So on any device whose microphone sits on input 2 — a 2-in interface with the
mic in the second socket, or an aggregate whose first sub-device is silent — the
gate metered a silent channel while the recorded mono had full signal: the
liveness wait never confirmed, and the press failed CLOSED with "The microphone
didn't start." on a mic that records perfectly. The retired
AVAudioRecorder.averagePower(forChannel: 0) metered the recorded mono downmix,
so channel 0 was the whole picture there; this was a behavior change that rode
inside the backend swap unnoticed, through a green check.sh both times.

Now a max over all channels. It slightly over-reads the true downmix (one loud
channel of two averages quieter once mixed), which is the harmless direction for
a floor probe and for meter bars.

Also from the same review pass:

- start()'s third suspension had no abandonment check. Moving the session build
  off-actor added it without one, which is precisely the drift the last commit
  predicted, so the guard is now one throwing `checkStillWanted(since:stage:)`
  called after each suspension — log line and throw included, so a fourth
  suspension point adds one line and cannot get the handling wrong.
- The mic picker showed "Disconnected microphone" for the 150-500 ms of a cold
  device read, because the empty initial array read as "the pin is gone". A
  `devicesLoaded` flag separates "not read yet" from "read, and empty" (a flag
  rather than an optional array: `discouraged_optional_collection` is opted into
  repo-wide, and check.sh caught it).
- A refused input or output logged nothing, leaving "no usable input device" or a
  fail-closed timeout with no explanation.
- Two stale references of my own: a doc naming `sessionQueue` after the rename to
  `controlQueue`, and AGENTS.md still describing the bring-up test as calibrating
  and skipping itself after fb67c86 removed both. That is the second false doc
  claim I've introduced in this branch and the second caught by a reviewer.

Accepted rather than fixed: the bring-up test's fixed 30 ms sleep. The safe
window is roughly [1 ms, 80 ms] — the press must have started, and must not have
completed — since the fastest open measured here is ~80 ms and the liveness gate
then waits on a meter that lags the first frames. 30 ms sits mid-window; moving
it to ~2 ms as suggested would trade the upper edge for the lower one. The
assumption is now named in the test.

Worth recording: both serious findings here live in files the coverage gate
excludes, reachable only through code with no injectable seam, and check.sh was
green through both. That is the concrete argument for the deferred recorder-seam
decision — a stub returning a fixed meteredPowerDB() would have caught the first,
and a stub whose make() suspends on a test-held continuation the second.

check.sh green: 630 tests, 6 live tests on real hardware, coverage 88.92%, both
sanitizers, swiftlint --strict, periphery, app build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The old comment blamed `#expect(throws:)` not being able to capture an
`async let`, which isn't the reason — the test cancels the press and then
inspects how it ended, so the handle has to outlive the statement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@alexkroman
alexkroman added this pull request to the merge queue Aug 26, 2026
Merged via the queue into main with commit 0fa7108 Aug 26, 2026
10 checks passed
@alexkroman
alexkroman deleted the feat/mic-device-selection branch August 26, 2026 21:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants